/*
 * SHOPTNT 版权所有。
 * 未经许可，您不得使用此文件。
 * 官方地址：www.shoptnt.cn
 */
package cn.shoptnt.configserver.resource;


import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.cloud.config.server.environment.SearchPathLocator;
import org.springframework.cloud.config.server.resource.GenericResourceRepository;
import org.springframework.cloud.config.server.resource.NoSuchResourceException;
import org.springframework.cloud.config.server.resource.ResourceRepository;
import org.springframework.context.ResourceLoaderAware;
import org.springframework.core.io.Resource;
import org.springframework.core.io.ResourceLoader;
import org.springframework.util.ResourceUtils;
import org.springframework.util.StringUtils;

import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.net.URLDecoder;
import java.util.Collection;
import java.util.LinkedHashSet;
import java.util.Set;

/**
 * 修复Spring Cloud Config Server 任意文件读取漏洞<br>
 * 重写 {@link GenericResourceRepository }
 * 详细见<a href="https://github.com/spring-cloud/spring-cloud-config/commit/9617f2922ee2ae27f08676716224933f0d869719">更新细节</a>
 *
 * 2021/4/19  12:03
 * @author liuyulei
 * @version 1.0
 * @since  7.2.2
 **/
public class GenericResourceNewRepository implements ResourceRepository, ResourceLoaderAware {
    private static final Log logger = LogFactory.getLog(GenericResourceNewRepository.class);

    private ResourceLoader resourceLoader;

    private SearchPathLocator service;

    public GenericResourceNewRepository(SearchPathLocator service) {
        this.service = service;
    }

    @Override
    public void setResourceLoader(ResourceLoader resourceLoader) {
        this.resourceLoader = resourceLoader;
    }

    @Override
    public synchronized Resource findOne(String application, String profile, String label, String path) {
        
        if (StringUtils.hasText(path)) {
            String[] locations = this.service.getLocations(application, profile, label)
                    .getLocations();
            try {
                for (int i = locations.length; i-- > 0; ) {
                    String location = locations[i];
                    for (String local : getProfilePaths(profile, path)) {
                        if (!isInvalidPath(local) && !isInvalidEncodedPath(local)) {
                            Resource file = this.resourceLoader.getResource(location)
                                    .createRelative(local);
                            if (file.exists() && file.isReadable()) {
                                return file;
                            }
                        }
                    }
                }
            }
            catch (IOException e) {
                throw new NoSuchResourceException(
                        "Error : " + path + ". (" + e.getMessage() + ")");
            }
        }
        throw new NoSuchResourceException("Not found: " + path);
    }

    /**
     * Check whether the given path contains invalid escape sequences.
     * @param path the path to validate
     * @return {@code true} if the path is invalid, {@code false} otherwise
     */
    private boolean isInvalidEncodedPath(String path) {
        if (path.contains("%")) {
            try {
                // Use URLDecoder (vs UriUtils) to preserve potentially decoded UTF-8 chars
                String decodedPath = URLDecoder.decode(path, "UTF-8");
                if (isInvalidPath(decodedPath)) {
                    return true;
                }
                decodedPath = processPath(decodedPath);
                if (isInvalidPath(decodedPath)) {
                    return true;
                }
            }
            catch (IllegalArgumentException | UnsupportedEncodingException ex) {
                // Should never happen...
            }
        }
        return false;
    }

    /**
     * Process the given resource path.
     * <p>The default implementation replaces:
     * <ul>
     * <li>Backslash with forward slash.
     * <li>Duplicate occurrences of slash with a single slash.
     * <li>Any combination of leading slash and control characters (00-1F and 7F)
     * with a single "/" or "". For example {@code "  / // foo/bar"}
     * becomes {@code "/foo/bar"}.
     * </ul>
     * @since 3.2.12
     */
    protected String processPath(String path) {
        path = StringUtils.replace(path, "\\", "/");
        path = cleanDuplicateSlashes(path);
        return cleanLeadingSlash(path);
    }


    private String cleanDuplicateSlashes(String path) {
        StringBuilder sb = null;
        char prev = 0;
        for (int i = 0; i < path.length(); i++) {
            char curr = path.charAt(i);
            try {
                if ((curr == '/') && (prev == '/')) {
                    if (sb == null) {
                        sb = new StringBuilder(path.substring(0, i));
                    }
                    continue;
                }
                if (sb != null) {
                    sb.append(path.charAt(i));
                }
            }
            finally {
                prev = curr;
            }
        }
        return sb != null ? sb.toString() : path;
    }


    private String cleanLeadingSlash(String path) {
        boolean slash = false;
        for (int i = 0; i < path.length(); i++) {
            if (path.charAt(i) == '/') {
                slash = true;
            }
            else if (path.charAt(i) > ' ' && path.charAt(i) != 127) {
                if (i == 0 || (i == 1 && slash)) {
                    return path;
                }
                return (slash ? "/" + path.substring(i) : path.substring(i));
            }
        }
        return (slash ? "/" : "");
    }


    /**
     * Identifies invalid resource paths. By default rejects:
     * <ul>
     * <li>Paths that contain "WEB-INF" or "META-INF"
     * <li>Paths that contain "../" after a call to
     * {@link org.springframework.util.StringUtils#cleanPath}.
     * <li>Paths that represent a {@link org.springframework.util.ResourceUtils#isUrl
     * valid URL} or would represent one after the leading slash is removed.
     * </ul>
     * <p><strong>Note:</strong> this method assumes that leading, duplicate '/'
     * or control characters (e.g. white space) have been trimmed so that the
     * path starts predictably with a single '/' or does not have one.
     * @param path the path to validate
     * @return {@code true} if the path is invalid, {@code false} otherwise
     * @since 3.0.6
     */
    protected boolean isInvalidPath(String path) {
        if (path.contains("WEB-INF") || path.contains("META-INF")) {
            if (logger.isWarnEnabled()) {
                logger.warn("Path with \"WEB-INF\" or \"META-INF\": [" + path + "]");
            }
            return true;
        }
        if (path.contains(":/")) {
            String relativePath = (path.charAt(0) == '/' ? path.substring(1) : path);
            if (ResourceUtils.isUrl(relativePath) || relativePath.startsWith("url:")) {
                if (logger.isWarnEnabled()) {
                    logger.warn("Path represents URL or has \"url:\" prefix: [" + path + "]");
                }
                return true;
            }
        }
        if (path.contains("..") && StringUtils.cleanPath(path).contains("../")) {
            if (logger.isWarnEnabled()) {
                logger.warn("Path contains \"../\" after call to StringUtils#cleanPath: [" + path + "]");
            }
            return true;
        }
        return false;
    }
    private Collection<String> getProfilePaths(String profiles, String path) {
        Set<String> paths = new LinkedHashSet<>();
        for (String profile : StringUtils.commaDelimitedListToSet(profiles)) {
            if (!StringUtils.hasText(profile) || "default".equals(profile)) {
                paths.add(path);
            }
            else {
                String ext = StringUtils.getFilenameExtension(path);
                String file = path;
                if (ext != null) {
                    ext = "." + ext;
                    file = StringUtils.stripFilenameExtension(path);
                }
                else {
                    ext = "";
                }
                paths.add(file + "-" + profile + ext);
            }
        }
        paths.add(path);
        return paths;
    }
}
